Skip to content

Deliver held outcomes to a definition that arrives after configure() - #67

Open
dmurphy5 wants to merge 7 commits into
masterfrom
dylan/deliver-late-definitions
Open

dmurphy5 wants to merge 7 commits into
masterfrom
dylan/deliver-late-definitions

Conversation

@dmurphy5

@dmurphy5 dmurphy5 commented Nov 9, 2026 •

Copy link
Copy Markdown

What was wrong

Metro loads modules lazily (inlineRequires). So a define() can run after configure(). configure() starts the replay of journaled outcomes. An outcome whose key had no definition yet emitted the unhandled-key row. Then it waited for the next launch. The definition arrived a moment later, but nothing routed the outcome to it.

Diana works around this in openspacelabs/diana#9555. It imports every definition before configure(). The PR "v11: configure() takes the definitions" fixed it with a breaking contract. This PR fixes it without a breaking change.

What changes

Late definitions receive their outcomes. Delivery now holds each outcome whose key has no definition. When define() registers that key, delivery routes the held outcomes again. They take the normal path: wait for a same-id mutate(), run the handler, then ack.

flowchart LR
  A[Outcome replays] --> B{Key defined?}
  B -- yes --> C[Handler, then ack]
  B -- no --> D[Emit unhandled-key row<br/>Hold the outcome]
  D -- "define() of the key" --> E[Join the id's lane<br/>in original order]
  E --> C
  D -- "no define() this session" --> F[Stays unacknowledged<br/>Redelivers next launch]
Loading
  • The unhandled-key row is still emitted, as before.
  • Held outcomes keep their order per id. A held outcome also goes before a later outcome of the same id that already waits in the lane.
  • A key that is never defined behaves as before. Its outcome stays unacknowledged for the next launch.
  • A define() that replaces a key does not deliver a held outcome twice.
  • cancel(id) drops the held outcomes of that id before and after native cancel. On a settled entry, native forgets those outcomes too. So a later define(), also one that runs while native cancels, must not run a handler for them. If native cancel rejects, the outcomes were never acknowledged, so native redelivers them at the next launch.
  • The unhandled-key row is not followed by a new state row when the outcome is handled. After the ack, a completed or cancelled entry leaves getRequests() without a state event. An error entry keeps its error row.

Optional configure({ definitions }). It takes the handles that define() returned. Listing a handle imports its module, so its define() runs before the replay. In development, a later define() of a key that is not in the list logs one warning per key:

define: "late" is not in configure({ definitions }). It still receives its outcomes, but list it so that its module loads before configure().

Nothing is rejected. An unlisted key still receives its outcomes, and mutate() does not wait. Without the option, nothing changes. A new exported type, AnyDefined, names one entry of the list. configure() throws only when definitions is not an array of handles.

Where to look

  • src/delivery.ts: hold(), release(), forget(), and the drain loop inline in route(). The loop is inline so that no await comes between the last empty check and the key lookup.
  • src/index.ts: onDefine() (dev warning and release()), the definitions check in configure(), and the forget() call in cancel().
  • src/registry.ts: the new onDefine hook, called after define() registers a key.
  • README rules 5, 6 and 8 under "Reliable delivery", and the configure() options table.

Known limit

Order holds among the outcomes of one key. If one id has held outcomes under two keys, the key that is defined first delivers first. A held outcome also goes after a later outcome of the same id whose handler started before the define() ran. Outcomes of one id normally share one key.

Test plan

  • yarn test: 6 suites, 214 tests pass (20 new: 12 in client.test.ts, 8 in delivery.test.ts).
  • yarn jest in example/RNBGUExample: 1 suite, 4 tests pass.
  • yarn typecheck, with a new type test for configure({ definitions }).
  • yarn lint:ci.
  • With release() disabled, 10 new tests fail. With the drain removed from route(), the lane-order test fails. With await drainReleased() in place of the inline loop, the microtask test fails at depth 1. Without forget() in cancel(), the cancel test fails. With forget() only after native cancel, the test that defines the key during the cancel fails.

🤖 Generated with Claude Code

https://claude.ai/code/session_011y2fcSHHE3i5eQHgATu6Tc

dmurphy5 and others added 2 commits November 9, 2026 12:07
Metro loads modules lazily, so a define() can run after configure() has
started the replay. An outcome for that key emitted the unhandled-key row
and then waited for the next launch, even though its definition arrived a
moment later.

Delivery now holds each outcome whose key has no definition. When define()
registers that key, the held outcomes route again through the normal path:
they wait for a same-id mutate(), run the handler, and ack. They keep their
order per id, also ahead of a later outcome of the id that already waits in
the lane. The unhandled-key row is still emitted. An outcome whose key is
never defined stays unacknowledged for the next launch, as before.

configure() also takes an optional definitions list, the handles that
define() returned. Listing a handle imports its module before the replay.
In development, a later define() of a key that is not listed warns once.
Nothing is rejected, and an unlisted key still receives its outcomes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011y2fcSHHE3i5eQHgATu6Tc
route() now runs its drain loop inline. Before, an empty drainReleased()
still yielded one microtask. A define() in that microtask let a newer
outcome of the same id and key find its definition and run before the
older held one.

cancel(id) now drops the held outcomes of that id after native cancel
resolves. On a settled entry native forgets the outcomes too, so a later
define() would have run a handler for a discarded entry.

The README and CHANGELOG now state the order limit across keys, and what a
consumer sees after the unhandled-key row. The unknown-key warning says
the outcome waits for define() or the next launch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011y2fcSHHE3i5eQHgATu6Tc
@dmurphy5
dmurphy5 marked this pull request as ready for review November 9, 2026 20:36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cancel(id) now forgets the id's held outcomes before the native call as
well as after it. A define() of the key that runs while native cancels
no longer runs a handler for the cancelled entry.

README rule 8 names the exception from rule 5: when one id has held
outcomes under two keys, the key defined first delivers first.

New tests: the cancel race, no second state row after a release, and one
id with held outcomes under two keys.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@elliottkember
elliottkember requested a review from a team November 12, 2026 17:13
Brings in the retry.transient rules (#62-#64) and the iOS chunked-file fix (#68).

Conflicts:
- CHANGELOG.md: kept master's retry.transient paragraph, then this branch's two paragraphs.
- src/index.ts configure(): master's retry check stays first; this branch's definitions check follows it. Both run before any setting changes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
dmurphy5 and others added 2 commits November 13, 2026 14:58
Brings in #65, #74, #78 and #80.

CHANGELOG.md: kept every entry; master's entries first, then this branch's
two (late define() delivers held outcomes, configure({ definitions })).
src/__typetests__/define.ts: kept both blocks (this branch's
configure({ definitions }) checks and master's takeDiagnostics() checks).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Brings in #71 (mutate() waits, onWarning).

CHANGELOG.md: kept every entry; master's entries first, then this branch's
two.
src/index.ts: createRegistry() gets both this branch's onDefine and #71's
warn. The warning for an unlisted late define() now goes through warn(),
so it reaches onWarning like every other JS warning (#71's rule).
src/__typetests__/define.ts: kept both blocks (configure({ definitions })
and onWarning).
src/__tests__/client.test.ts: #71's onWarning test now expects this
branch's text for the no-definition warning ("... until define()
registers the key, or until the next launch."). Added one test: the
unlisted late define() warning goes to onWarning, not console.warn. Both
as in the integration branch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant